import satori from "satori"; import { Resvg } from "@resvg/resvg-js"; import sharp from "sharp"; import { readFileSync, existsSync, readdirSync, writeFileSync, mkdirSync, } from "node:fs"; import path from "node:path"; const W = 1200; const H = 630; const contentDir = "src/content/docs"; const pages: Record = { index: "だれもがしあわせに暮らせるまちへ", jisseki: "実績", policy: "私の方針", support: "ご支援", contact: "コンタクト", "whisper-to-ai-moji-okoshi": "無料・超高精度のWhisperで文字起こしする方法(+α)", "textlint-guide": "textlintで文章校正する方法", "bluebey": "ぶるべー スタジオ", "koubunsyo-kanri": "公文書管理の不正追及の軌跡", "gyosei-fufuku-sinsa": "行政不服審査会の問題追及の軌跡", "ijime-judai-jitai": "いじめ重大事態への対応の軌跡", "fukushi-shisetsu-gyakutai": "障害者福祉施設における虐待通報対応の軌跡", "aiki-kouen": "合気公園の軌跡", "joutyo-koteikyu": "情緒固定級の軌跡", "kajo-seigen-kanwa": "過剰な制限緩和の軌跡", "saresio-kaihatu": "東京サレジオ学園北側開発問題の軌跡", "vaccine-kyuusai-tekiseika": "ワクチン副反応救済制度の適正化の軌跡", "dislexia-taiou": "ディスレクシア(読み書き障害)対応の軌跡", "konan-eisei-kumiai": "湖南衛生組合の議員報酬・給与見直しの軌跡", "ippan-situmon": "一般質問", media: "メディア掲載", }; function findMdxFiles(dir: string): string[] { if (!existsSync(dir)) return []; const files: string[] = []; for (const entry of readdirSync(dir, { withFileTypes: true })) { if (entry.name.startsWith(".") || entry.name.startsWith("_")) continue; const full = path.join(dir, entry.name); if (entry.isDirectory()) files.push(...findMdxFiles(full)); else if (entry.name.endsWith(".mdx")) files.push(full); } return files; } function readTitle(filePath: string): string | null { try { const content = readFileSync(filePath, "utf-8"); const m = content.match(/^title:\s*"(.+?)"/m) ?? content.match(/^title:\s*(.+)/m); return m?.[1] ?? null; } catch { return null; } } export async function getStaticPaths() { const slugs = new Set(); // Manual pages for (const s of Object.keys(pages)) slugs.add(s); // Auto-discover ippan-situmon / iinkai pages for (const dir of ["ippan-situmon", "iinkai", "kodaira-data"]) { for (const f of findMdxFiles(path.join(contentDir, dir))) { const s = f .replace(/\\/g, "/") .replace(contentDir + "/", "") .replace(/\.mdx$/, "") .replace(/\/index$/, ""); if (s) slugs.add(s); } } return [...slugs] .filter((s) => s.length > 0) .map((slug) => ({ params: { slug: slug.replace(/\//g, "--") } })); } const fontBuffer = readFileSync("node_modules/.noto-sans-jp.otf"); const faceiconBuffer = readFileSync("public/img/faceicon.jpg"); const faceiconBase64 = `data:image/jpeg;base64,${faceiconBuffer.toString("base64")}`; const NAME = "小平市議 安竹洋平"; const LABEL = "KODAIRA CITY COUNCIL"; const TAGLINE = "だれもが しあわせに 暮らせるまちへ"; // 星空背景は全ページ共通なので、ビルド中に1回だけ生成して使い回す。 function mulberry32(a: number) { return function () { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } const baseGradient = () => Buffer.from( ``, ); const nebulaSvg = () => Buffer.from( ``, ); const milkyWay = () => Buffer.from( ``, ); const arcsSvg = () => Buffer.from( ``, ); function starsSvg(seed: number, n: number) { const rnd = mulberry32(seed); let s = ``; for (let i = 0; i < n; i++) { const x = rnd() * W; const y = rnd() * H; const r = 0.35 + rnd() * 1.8; const o = 0.18 + rnd() * 0.75; const col = rnd() > 0.82 ? "#dbeafe" : "#ffffff"; s += ``; } for (let i = 0; i < 8; i++) { const x = 520 + rnd() * 660; const y = 40 + rnd() * 550; const size = 5 + rnd() * 9; const o = 0.6 + rnd() * 0.38; const col = rnd() > 0.5 ? "#e0f2fe" : "#ffffff"; s += ``; const q = size * 0.22; s += ``; } s += ``; return Buffer.from(s); } const leftOverlay = () => Buffer.from( ``, ); async function buildBackground(): Promise { const base = await sharp(baseGradient()).png().toBuffer(); const neb = await sharp(nebulaSvg()).blur(80).png().toBuffer(); const mw = await sharp(milkyWay()).blur(55).png().toBuffer(); return sharp(base) .composite([ { input: neb }, { input: mw, blend: "screen" }, { input: arcsSvg() }, { input: starsSvg(20261104, 260) }, { input: leftOverlay() }, ]) .png() .toBuffer(); } let backgroundPromise: Promise | null = null; function getBackground(): Promise { if (!backgroundPromise) backgroundPromise = buildBackground(); return backgroundPromise; } function textLayer(title: string) { // 長いタイトルでもはみ出さないよう、文字数に応じて少しだけ縮める const titleSize = title.length > 40 ? 46 : title.length > 30 ? 52 : 60; // ホームはタイトルとタグラインが実質同じなので、タグラインを省く const isHome = title.replace(/\s/g, "") === TAGLINE.replace(/\s/g, ""); return { type: "div", props: { style: { display: "flex", width: `${W}px`, height: `${H}px`, alignItems: "center", padding: "64px 80px", gap: "56px", position: "relative", fontFamily: "Noto", color: "#ffffff", }, children: [ { type: "div", props: { style: { display: "flex", padding: "6px", borderRadius: "50%", background: "linear-gradient(135deg,#22d3ee,#818cf8)", flexShrink: "0", }, children: [ { type: "img", props: { src: faceiconBase64, width: 168, height: 168, style: { borderRadius: "50%" }, }, }, ], }, }, { type: "div", props: { style: { display: "flex", flexDirection: "column", gap: "18px", flex: "1", }, children: [ { type: "div", props: { style: { fontSize: "22px", letterSpacing: "3.5px", color: "rgba(214,224,255,0.85)", }, children: LABEL, }, }, { type: "div", props: { style: { fontSize: `${titleSize}px`, fontWeight: 700, lineHeight: 1.16, letterSpacing: "-0.01em", }, children: title, }, }, { type: "div", props: { style: { width: "128px", height: "7px", borderRadius: "4px", background: "linear-gradient(90deg,#22d3ee,#818cf8)", }, }, }, ...(isHome ? [] : [ { type: "div", props: { style: { fontSize: "30px", color: "#c7d2fe" }, children: TAGLINE, }, }, ]), { type: "div", props: { style: { display: "flex", alignItems: "center", paddingTop: "16px", borderTop: "1px solid rgba(255,255,255,0.22)", fontSize: "31px", fontWeight: 700, color: "#ffffff", }, children: [{ type: "div", props: { children: NAME } }], }, }, ], }, }, ], }, }; } export async function GET({ params }: { params: { slug: string } }) { const slug = params.slug; // まずそのままマッチ(koubunsyo-kanri などの手動ページ) let title = pages[slug]; if (!title) { const fileSlug = slug.replace(/--/g, "/"); title = pages[fileSlug]; if (!title) { title = readTitle(path.join(contentDir, fileSlug + ".mdx")) ?? readTitle(path.join(contentDir, fileSlug, "index.mdx")) ?? "小平市議 安竹洋平 公式サイト"; } } // 一般質問の月例会インデックスページ: 年度・月を補完 const mm = slug.match(/^ippan-situmon--(r\d+d)--(\d+gatu)$/); if (mm) { const reiwa = mm[1].replace( /^r(\d+)d$/, (_, n) => `令和${n === "1" ? "元" : n}年`, ); const month = mm[2].replace(/(\d+)gatu/, "$1月"); title = `${reiwa}${month}定例会`; } // Check cache: if title hash matches, return cached JPEG from node_modules/.og-cache const cacheDir = path.join(process.cwd(), "node_modules", ".og-cache"); const cacheFile = path.join(cacheDir, `${slug}.jpg`); const cacheJsonPath = path.join(cacheDir, ".og-cache.json"); let cache: Record = {}; if (existsSync(cacheJsonPath)) { cache = JSON.parse(readFileSync(cacheJsonPath, "utf-8")); } const { createHash } = await import("node:crypto"); // デザインを変えたらこのバージョンを上げる(キャッシュを無効化する) const DESIGN_VERSION = "space-v9b-3"; const titleHash = createHash("md5") .update(`${DESIGN_VERSION}|${title}`) .digest("hex"); if (existsSync(cacheFile) && cache[slug] === titleHash) { const cachedBuffer = readFileSync(cacheFile); return new Response(cachedBuffer, { headers: { "Content-Type": "image/jpeg", "Cache-Control": "public, max-age=31536000, immutable", }, }); } const bg = await getBackground(); const svg = await satori(textLayer(title) as any, { width: W, height: H, fonts: [ { name: "Noto", data: fontBuffer, weight: 400, style: "normal" }, { name: "Noto", data: fontBuffer, weight: 700, style: "normal" }, ], }); const pngBuffer = new Resvg(svg, { fitTo: { mode: "width", value: W } }) .render() .asPng(); // テキスト層を星空背景に重ねて JPEG で出力する const jpgBuffer = await sharp(bg) .composite([{ input: pngBuffer }]) .jpeg({ quality: 88 }) .toBuffer(); // Save to cache in node_modules/.og-cache (persists across builds) if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true }); writeFileSync(cacheFile, jpgBuffer); cache[slug] = titleHash; writeFileSync(cacheJsonPath, JSON.stringify(cache, null, 2)); return new Response(jpgBuffer, { headers: { "Content-Type": "image/jpeg", "Cache-Control": "public, max-age=31536000, immutable", }, }); }